<?php
/**
 * WB_S3 class
 * Amazon S3
 * 
 * @author Jean-Lou Dupont
 * @version @@package-version@@
 * @id $Id: S3.php 168 2008-03-31 13:02:45Z jldupont $
 * 
 * Dependencies:
 * ===========
 * PEAR Net_IPv4  ( pear install Net_IPv4 )
 * PEAR Crypt_HMAC ( pear install Crypt_HMAC ) ( S3Request.php )
 * PEAR HTTP_Request ( S3Request.php )
 * 
 * TODO
 * ====
 * > support meta fields e.g. x-amz-acl, x-amz-meta
 *   just a little bit more todo for this to work
 */

 //{{include
 #require_once 'web-bloks/BaseClass/BaseClass.php';
 require_once 'web-bloks/AmazonS3/responses/S3Response.php'; 
 require_once 'web-bloks/AmazonS3/responses/S3RawResponse.php';  
 require_once 'web-bloks/AmazonS3/responses/S3Response_BucketList.php';
 require_once 'web-bloks/AmazonS3/responses/S3Response_ObjectList.php';
 require_once 'Net/IPv4.php';
 //include}}
 
 class WB_S3
 	extends WB_BaseClass
 {
 	/**
 	 * Last raw response
 	 */
	var $_responseRaw = null;
		
 	/**
 	 * If network errors should trigger exceptions.
 	 */
	static $stateNetworkErrorsAsException = false;

	/**
	 * AMZ access key
	 * @access private
	 */
	var $accessKey = null;

	/**
	 * AMZ secret key
	 * @access private
	 */
	var $secretKey = null;

	// %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%		
	// CONSTRUCTOR
	// %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%				
 	/**
 	 * Constructor
 	 */
	public function __construct( $params = null )
	{
		parent::__construct( $params );
	}
	// %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%		
	// WB_BaseClass
	// %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%				
	/**
	 * @see WB_BaseClass::getClassPath
	 */
	public function getClassPath()
	{
		return 'web-bloks/AmazonS3';
	}
	/**
	 * @see WB_BaseClass::getClassVersion
	 * @return 
	 */
	public function getClassVersion()
	{
		return '$Id: S3.php 168 2008-03-31 13:02:45Z jldupont $';
	}
	// ===================================================================
	// PUBLIC interface
	// ===================================================================	
	/**
	 * Sets the AMZ keys.
	 * 
	 * @return $this
	 * @param $access String
	 * @param $secret String
	 */	
	public function setKeys( $access, $secret )
	{
		$this->accessKey = $access;
		$this->secretKey = $secret;
		return $this;	
	}
	/**
	 * Configures the class for generating exceptions
	 * when network related errors occured
	 * 
	 * @return 
	 * @param $state Object[optional]
	 */
	public static function setNetworkErrorsAsException( $state = true )
	{
		self::$stateNetworkErrorsAsException = $state;
	}
	/**
	 * Returns the last recorded error code
	 * @return null OR numeric
	 */
	public function getLastCode()
	{
		if ( is_object( $this->_rawResponse ))
			return $this->_rawResponse->getCode();
		return null;
	}	
	/**
	 * Returns the last recorded error message
	 * @return 
	 */
	public function getLastMsg()
	{
		if ( is_object( $this->_rawResponse ))
			return $this->_rawResponse->getMsg();
		return null;
	}
	// ===================================================================
	// BUCKET related methods
	// ===================================================================	
	
	/**
	 * Gets the list of bucket(s)
	 * 
	 * @return WB_S3Response_BucketList
	 * @throws
	 */
	public function & getBucketList()
	{
		// PREPARE the request
		$req = WB_ObjFactory::factory( "WB_S3Request" );
		$this->configKeys( $req );
		$req->setVerb( "GET" )
			->setResource( "/" );
			
		// SEND	the request and wait for the response
		$this->_rawResponse = $req->send();
		
		// CHECK for errors
		if ( $this->dealWithErrors( $this->_rawResponse ) )
			return $this->_rawResponse;
		
		// RETURN the proper object response
		return new WB_S3Response_BucketList( $this->_rawResponse );
	}
	/**
	 * Creates a bucket
	 * 
	 * RESPONSE HEADERS:
	 * > x-amz-id-2
	 * > x-amz-request-id
	 * > Date
	 * > Location
	 * > Content-Length
	 * 
	 * @return boolean true if no error
	 * @return WB_S3RawResponse if error
	 * @throws
	 * @param $bucket string
	 */
	public function createBucket( $bucket )
	{
		if ( !$this->validateBucketName( $bucket ) )
			return false;
		
		$req = WB_ObjFactory::factory( "WB_S3Request" );	
		$this->configKeys( $req );		
		$req->setVerb( "PUT" )
			->setResource( $bucket );

		// SEND	the request and wait for the response
		$this->_rawResponse = $req->send();

		// CHECK for errors
		if ( $this->dealWithErrors( $this->_rawResponse ) )
			return $this->_rawResponse;

		return true;		
	}
	/**
	 * Deletes a bucket
	 * 
	 * RESPONSE HEADERS:
	 * > x-amz-id-2
	 * > x-amz-request-id
	 * > Date
	 * 
	 * @return boolean true if no error
	 * @return WB_S3RawResponse if error
	 * @throws
	 * @param $bucket string
	 */
	public function deleteBucket( $bucket )
	{
		$req = WB_ObjFactory::factory( "WB_S3Request" );	
		$this->configKeys( $req );		
		$req->setVerb( "DELETE" )
			->setResource( $bucket );

		// SEND	the request and wait for the response
		$this->_rawResponse = $req->send();
		
		// CHECK for errors
		if ( $this->dealWithErrors( $this->_rawResponse ) )
			return $this->_rawResponse;

		return true;		
	}
	
	// ===================================================================
	// OBJECT related methods
	// ===================================================================	
	/**
	 * Gets the object list of a bucket
	 * 
	 * RESPONSE HEADERS:
	 * > x-amz-id-2
	 * > x-amz-request-id
	 * > Date
	 * > Content-Type: [application/xml]
	 * > Content-Length
	 * 
	 * @return 
	 * @param $bucket Object
	 */
	public function & getObjectList(	$bucket, 
										$max = null, 
										$prefix = null,
										$delimiter = null,
										$marker = null )
	{
		$req = WB_ObjFactory::factory( "WB_S3Request" );	
		$this->configKeys( $req );		
		$req->setVerb( "GET" )
			->setResource( $bucket );
			
		// PARAMETERS
		$req->setURIParams( 
			array(	'max-keys'	=> $max,
					'prefix'	=> $prefix,
					'delimiter' => $delimiter,
					'marker'	=> $marker
				)
		);
		
		// SEND	the request and wait for the response
		$this->_rawResponse = $req->send();

		// CHECK for network errors
		if ( $this->dealWithErrors( $this->_rawResponse ) )
			return $this->_rawResponse;

		return new WB_S3Response_ObjectList( $this->_rawResponse );	
	}	
	/**
	 * Deletes an object
	 * 
	 * REQUEST HEADERS:
	 * 
	 * RESPONSE HEADERS:
	 * > x-amz-id-2
	 * > x-amz-request-id
	 * > Date  [creation date?]

	 * @return 
	 * @param $bucket string
	 * @param $name string
	 */	
	public function deleteObject( $bucket, $name )
	{
		$req = WB_ObjFactory::factory( "WB_S3Request" );	
		$this->configKeys( $req );		
		$req->setVerb( "DELETE" )
			->setResource( $bucket.'/'.$name );
			
		// SEND	the request and wait for the response
		$this->_rawResponse = $req->send();

		// CHECK for network errors
		if ( $this->dealWithErrors( $this->_rawResponse ) )
			return $this->_rawResponse;

		return true;
	}	
	/**
	 * Gets (HEAD) object information
	 * 
	 * REQUEST HEADERS:
	 * 
	 * RESPONSE HEADERS:
	 * > x-amz-id-2
	 * > x-amz-request-id
	 * > Date  [creation date?]
	 * > x-amz-meta-family
	 * > Last-Modified
	 * > ETag
	 * > Content-Type
	 * > Content-Length
	 * 
	 * @return 
	 * @param $bucket string
	 * @param $name string
	 */	
	public function getObjectInfo( $bucket, $name )
	{
		$req = WB_ObjFactory::factory( "WB_S3Request" );	
		$this->configKeys( $req );		
		$req->setVerb( "GET" )
			->setResource( $bucket );
			
		// PARAMETERS
		$req->setURIParams( 
			array(	'max-keys'	=> $max,
					'prefix'	=> $prefix,
					'delimiter' => $delimiter,
					'marker'	=> $marker
				)
		);
		
		// SEND	the request and wait for the response
		$this->_rawResponse = $req->send();

		// CHECK for network errors
		if ( $this->dealWithErrors( $this->_rawResponse ) )
			return $this->_rawResponse;

		return new WB_S3Response_ObjectList( $this->_rawResponse );	
	}	
	/**
	 * Gets (conditionally) an object
	 * 
	 * REQUEST Headers
	 * > If-Modified-Since
	 * > If-Unmodified-Since
	 * > If-Match
	 * > If None-Match
	 * > Range [not supported]
	 * 
	 * RESPONSE HEADERS:
	 * > x-amz-meta
	 * > Content-Type
	 * > Content-Disposition
	 * > Content-Range [not supported]
	 * > x-amz-missing-meta
	 * 
	 * @return 
	 * @param $bucket string
	 * @param $name string
	 * @param $condition Object
	 */	
	public function getObject( $bucket, $name, $condition = null )	
	{
		$req = WB_ObjFactory::factory( "WB_S3Request" );	
		$this->configKeys( $req );		
		$req->setVerb( "GET" )
			->setResource( $bucket.'/'.$name )
			->setDocument( $document );

		// make sure we only have valid header fields
		if ( ( $field = $this->verifyHeaders( $condition, self::$fieldsGetObject ) !== true ))
			$this->throwException( __METHOD__.":invalid field ($field)" );

		// set the extra headers
		$this->setHeaders( $req, $condition, self::$fieldsGetObject );

		// SEND	the request and wait for the response
		$this->_rawResponse = $req->send();

		// CHECK for network errors
		if ( $this->dealWithErrors( $this->_rawResponse ) )
			return $this->_rawResponse;

		return $this->_rawResponse->getBody();
	}
	/**
	 * Allowed fields for the GET Object method
	 */
	static $fieldsGetObject = array(

		'if-modified-since'		=> array( 'type' => 's', 'n' => 'IfModifiedSince' ),
		'if-unmodified-since'	=> array( 'type' => 's', 'n' => 'IfUnmodifiedSince' ), 
		'if-match'				=> array( 'type' => 's', 'n' => 'IfMatch' ),
		'if-none-match'			=> array( 'type' => 's', 'n' => 'IfNoneMatch' ),
	);
	/**
	 * Creates/replaces an object
	 * 
	 * Request Headers
	 * > Cache-Control
	 * > Content-Type
	 * > Content-Length
	 * > Content-MD5
	 * > Content-Disposition
	 * > Content-Encoding
	 * > Expires
	 * > x-amz-acl-xyz  [4kb max]
	 * > x-amz-meta-xyz [4kb max]
	 * 
	 * RESPONSE Headers
	 * > ETag [for use with e.g. If-Modified...]
	 * 
	 * @return 
	 * @param $bucket string
	 * @param $name string
	 * @param $document string
	 * @param $headers array [optional] e.g. content-type
	 */	
	public function putObject( $bucket, $name, $document, $headers = null )
	{
		$req = WB_ObjFactory::factory( "WB_S3Request" );
		$this->configKeys( $req );			
		$req->setVerb( "PUT" )
			->setResource( $bucket.'/'.$name )
			->setDocument( $document );

		// make sure we only have valid header fields
		if ( ( $field = $this->verifyHeaders( $headers, self::$fieldsPutObject ) !== true ))
			$this->throwException( __METHOD__.":invalid field ($field)" );

		// set the extra headers
		$this->setHeaders( $req, $headers, self::$fieldsPutObject );

		// SEND	the request and wait for the response
		$this->_rawResponse = $req->send();
		
		// CHECK for network errors
		if ( $this->dealWithErrors( $this->_rawResponse ) )
			return $this->_rawResponse;

		return true;
	}	
	/**
	 * Allowed fields for the PUT Object method
	 */
	static $fieldsPutObject = array(

		//'content-length'		=> 's',  // normally set by the HTTP_Request class
		//'content-disposition'	=> 's',  // normally set by the HTTP_Request class
		//'content-md5'			=> 's',  // handle by S3Request
			
		'cache-control'			=> array( 'type' => 's', 'n' => 'CacheControl' ),
		'content-type'			=> array( 'type' => 's', 'n' => 'ContentType' ),
		'content-encoding'		=> array( 'type' => 's', 'n' => 'ContentEncoding' ), 
		'expires'				=> array( 'type' => 's', 'n' => 'Expires' ),
		//@TODO'x-amz-acl-'			=> array( 'type' => 'c', 'n' => 'xamzacl' ),
		//@TODO'x-amz-meta-'			=> array( 'type' => 'c', 'n' => 'xamz-meta-' ),
		
	);
	
	// ===================================================================
	// PUBLIC HELPER methods
	// ===================================================================	
	public function validateBucketName( $bucket )
	{
		if ( !is_string( $bucket ))
			return false;

		$len = strlen( $bucket );
		
		// must be between 3 and 255 characters long
		if ( ( $len <3 ) || ( $len > 255 ) )
			return false;

		// must be all lowercase
		if ( strtolower( $bucket ) !== $bucket )
			return false;

		// can't have / or \
		if ( strpbrk( $bucket, '\/_' ) !== false )	
			return false;
			
		// must start with a letter or number
		$start = $bucket[ 0 ] ;
		if ( strpos( "abcdefghijklmnopqrstuvwxyz0123456789", $start ) === false )
			return false;
			
		// invalid combination of .- -.			
		if ( strpos( $bucket, ".-" ) !== false )
			return false;
		if ( strpos( $bucket, "-." ) !== false )
			return false;
			
		// can't be an IP address either		
		if ( Net_IPv4::validateIP($bucket) )
			return false;
			
		// finally, can't have have a dash at the end
		// can't be negative because we would have caught
		// it in the above test.
		if ( $bucket[ $len-1 ] === '-' )			
			return false;
			
		return true;			
	}
	// ===================================================================
	// INTERNAL methods
	// ===================================================================	
	/**
	 * Configures the request object's AMZ keys.
	 * 
	 * @return $this
	 * @param $req Object
	 */
	protected function configKeys( &$req )
	{
		$req->setKeys( $this->accessKey, $this->secretKey );
		return $this;
	}
	/**
	 * Generates an exception if a network error
	 * occured AND $stateNetworkErrorsAsException prescribes it.
	 * 
	 * @return boolean true if error
	 * @throws
	 * @param $rawResponse Object
	 */
	protected function dealWithErrors( $rawResponse )
	{
		// network error?
		if ( $rawResponse === false )
		{
			if ( self::$stateNetworkErrorsAsException === true )
			{
				$resource = $rawResponse->getResource();
				$this->throwException( "network error (${resource})" );
			}
			
			return true;
		}
		// other error type?
		return ( $rawResponse->isError() );
	}
	/**
	 * Verify all headers
	 * 
	 * @return boolean
	 * @param $headers array
	 * @param $ref array
	 */	
	protected function verifyHeaders( &$headers, &$ref )
	{
		// none so this is OK
		if ( empty( $headers ))
			return true;
		
		// check for headers that should be there
		$result = true; // optimistic...
		foreach( $headers as $key => $value )
		{
			$keyLC = strtolower( $key );
			
			// check for the 's' (simple) header case
			if ( isset( $ref[ $keyLC ] ))
				continue;
				
			// could it be a complex one?
			foreach( $ref as $rkey => $data )
			{
				$type = $data[ 'type' ];

				if ( $type === 'c' )
					if ( strpos( $keyLC, $rkey ) !== false )
						continue;
			}			
			// if we get here...	
			$result = false;
			break;
		}		
		
		return $result;
	}
	/**
	 * Sets all headers
	 * 
	 * @return void
	 * @param $req Object
	 * @param $headers array
	 */	
	protected function setHeaders( &$req, &$headers, &$ref )
	{
		// none so this is OK
		if ( empty( $headers ))
			return;

		foreach( $headers as $key => &$value )
		{
			$name = $key;
			if ( isset( $ref[ $key ] ))
				$name = $ref[ $key ]['n'];

			$req->setHeaderField( $key, $value );
		}
	}
	
 } // end class declaration